SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
14.4 KB · 198 lines tsx
Raw Blame History
1import Link from "next/link";2import type { Metadata } from "next";3import { notFound } from "next/navigation";4import { PRIORITY_LABELS } from "@websensor/core/client";5import { EventRow } from "@/components/event-row";6import { FieldChangeInline } from "@/components/field-changes";7import { Chip, Empty, ExtLink, HealthPill, PageHeader, Panel, Stat, Table, Td, TierBadge } from "@/components/ui";8import { api, type SnapshotRow } from "@/lib/api";9import { CLASS_LABELS, dayHeader, fmtBytes, fmtDuration, fmtInt, fmtMs, relTime, shortHash, untilTime, utcDate, utcDateTime, utcTime } from "@/lib/format";1011export const dynamic = "force-dynamic";1213export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {14  const { id } = await params;15  const d = await api.sensor(id);16  return { title: d ? `${d.sensor.source_name} · ${d.sensor.name} — sensor` : "Sensor not found", robots: { index: false } };17}1819/** Sensor page with historical memory (spec §79): how this page looked over time. */20export default async function SensorPage({ params }: { params: Promise<{ id: string }> }) {21  const { id } = await params;22  const d = await api.sensor(id);23  if (!d) notFound();24  const s = d.sensor;25  const snaps = await api.sensorSnapshots(s.id, 200);26  const history = snaps.items.length ? snaps.items : d.snapshots;27  const runs = s.total_runs ?? 0;28  const checks24 = s.checks_24h ?? 0;29  const nm24 = checks24 ? Math.round(((s.not_modified_24h ?? 0) / checks24) * 100) : null;30  const nmAll = runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : null;31  const events = d.events ?? [];32  const prio = s.priority ?? null;33  // Group snapshots by UTC day (newest first, API order).34  const days: { day: string; rows: SnapshotRow[] }[] = [];35  for (const sn of history) {36    const day = utcDate(sn.captured_at);37    const last = days[days.length - 1];38    if (last && last.day === day) last.rows.push(sn);39    else days.push({ day, rows: [sn] });40  }41  const changeCount = history.filter((x) => x.has_change).length;42  return (43    <>44      <PageHeader45        compact46        kicker={47          <span className="flex flex-wrap items-center gap-2">48            <Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_name}</Link>49            <span className="text-fg-subtle">/</span>50            <span className="font-mono">{s.type}</span>51            <span className="text-fg-subtle">·</span>52            <span className="font-mono text-fg-muted">{s.connector}</span>53            <TierBadge tier={s.tier} />54            {prio !== null && <Chip tone={prio === 0 ? "hot" : prio === 1 ? "high" : "default"} className="font-mono" title={PRIORITY_LABELS[prio] ?? `Priority ${prio}`}>P{prio}</Chip>}55            {s.status && <HealthPill health={s.enabled ? s.status : "DISABLED"} />}56            <HealthPill health={s.enabled ? s.health : "DISABLED"} />57          </span>58        }59        title={s.name}60        description={<ExtLink href={s.url} className="font-mono text-[12px] break-all">{s.url}</ExtLink>}61        actions={62          <>63            <Link href={`/url?u=${encodeURIComponent(s.url)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">URL history →</Link>64            {s.domain && <Link href={`/domain/${s.domain}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Domain →</Link>}65          </>66        }67      />6869      <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 lg:grid-cols-8 lg:divide-y-0">70        <Stat label="Checks · 24 h" value={fmtInt(checks24)} hint={`${fmtInt(runs)} total`} />71        <Stat label="304 · 24 h" value={nm24 !== null ? `${nm24}%` : "—"} hint={nmAll !== null ? `${nmAll}% all-time` : s.has_etag || s.has_last_modified ? "validators exposed" : "no validators"} />72        <Stat label="Errors · 24 h" value={fmtInt(s.errors_24h ?? 0)} tone={(s.errors_24h ?? 0) > 0 ? "warn" : undefined} hint={s.consecutive_errors ? <span className="text-danger">{s.consecutive_errors} consecutive</span> : "0 consecutive"} />73        <Stat label="Avg latency" value={fmtMs(s.avg_ms_24h ?? s.avg_latency_ms)} hint={s.avg_ms_24h !== null && s.avg_ms_24h !== undefined ? "last 24 h" : "all-time"} />74        <Stat label="Raw / meaningful" value={<span>{fmtInt(s.raw_changes)}<span className="text-fg-subtle"> / </span><span className="text-signal">{fmtInt(s.meaningful_changes)}</span></span>} hint={s.raw_changes ? `noise ${Math.round((1 - (s.meaningful_changes ?? 0) / Math.max(1, s.raw_changes)) * 100)}%` : undefined} />75        <Stat label="Snapshots" value={fmtInt(history.length)} hint={`${changeCount} with a change`} />76        <Stat label="Last check" value={s.last_check_at ? relTime(s.last_check_at) : "never"} hint={s.last_status ? `HTTP ${s.last_status}` : undefined} />77        <Stat label="Next check" value={untilTime(s.next_check_at)} hint={s.current_interval_seconds ? `every ${fmtDuration(s.current_interval_seconds)}` : s.base_interval_seconds ? `base ${fmtDuration(s.base_interval_seconds)}` : "adaptive"} />78      </div>79      {s.last_error && (80        <div className="mb-4 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 font-mono text-[12px] text-danger break-words">81          last error · {s.last_error}82        </div>83      )}8485      <div className="grid gap-4 xl:grid-cols-[1fr_380px]">86        <div className="flex min-w-0 flex-col gap-4">87          <Panel title={`How this page looked over time · ${history.length} snapshot${history.length === 1 ? "" : "s"}`} dense action={<span className="hidden text-[11px] text-fg-subtle sm:inline">raw bodies may be pruned by retention · canonical form and hashes are kept</span>}>88            {history.length === 0 ? (89              <Empty>No snapshot yet — the first successful check stores the initial state of this page.</Empty>90            ) : (91              <div className="overflow-x-auto">92                <table className="w-full text-[12.5px]">93                  <thead>94                    <tr className="border-b border-line text-left">95                      {["Captured", "HTTP", "Size", "Canonical hash", "Change", "Event", "Raw", "Compare"].map((h) => (96                        <th key={h} className="label whitespace-nowrap px-3 py-2 font-semibold">{h}</th>97                      ))}98                    </tr>99                  </thead>100                  {days.map((g) => (101                    <tbody key={g.day} className="divide-y divide-line border-b border-line">102                      <tr className="bg-panel-2/50">103                        <td colSpan={8} className="px-3 py-1 font-mono text-[10.5px] font-semibold tracking-wider text-fg-subtle">{dayHeader(g.rows[0]!.captured_at)} <span className="font-normal">· {g.rows.length} capture{g.rows.length === 1 ? "" : "s"}</span></td>104                      </tr>105                      {g.rows.map((sn) => {106                        const idx = history.indexOf(sn);107                        const prev = history[idx + 1];108                        return (109                          <tr key={sn.id} className={`hover:bg-panel-2/60 ${sn.has_change ? "border-l-2 border-l-signal/60" : "border-l-2 border-l-transparent"}`}>110                            <Td mono className="whitespace-nowrap"><span title={utcDateTime(sn.captured_at)}>{utcTime(sn.captured_at)}</span> <span className="text-fg-subtle">{relTime(sn.captured_at)}</span></Td>111                            <Td mono className={sn.http_status && sn.http_status >= 400 ? "text-warn" : "text-fg-muted"}>{sn.http_status ?? "—"}</Td>112                            <Td mono className="whitespace-nowrap">{fmtBytes(sn.content_length)}</Td>113                            <Td mono className="text-fg-subtle"><span title={sn.canonical_hash ?? undefined}>{shortHash(sn.canonical_hash)}</span>{sn.mode ? <span className="ml-1.5 text-[10.5px]">{sn.mode}</span> : null}</Td>114                            <Td>{sn.has_change ? <Chip tone="signal" className="font-mono">CHANGED</Chip> : <span className="text-fg-subtle">—</span>}</Td>115                            <Td>{sn.event_slug ? <Link href={`/event/${sn.event_slug}`} className="text-info hover:underline">event →</Link> : <span className="text-fg-subtle">—</span>}</Td>116                            <Td>117                              {sn.has_raw === false ? (118                                <span className="cursor-not-allowed text-fg-subtle line-through" title="raw body pruned by retention">view raw</span>119                              ) : (120                                <a href={`/api/v1/snapshots/${sn.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a>121                              )}122                            </Td>123                            <Td>{prev ? <Link href={`/compare?a=${prev.id}&b=${sn.id}`} className="whitespace-nowrap text-info hover:underline">compare with previous</Link> : <span className="text-fg-subtle">first capture</span>}</Td>124                          </tr>125                        );126                      })}127                    </tbody>128                  ))}129                </table>130              </div>131            )}132          </Panel>133134          <Panel title={`Changes · ${d.changes.length}`} dense>135            {d.changes.length ? (136              <Table head={["Detected", "Kind", "Class", "Signal", "Meaningful", "What changed", "Links"]}>137                {d.changes.map((c) => (138                  <tr key={c.id} className="hover:bg-panel-2/60">139                    <Td mono className="whitespace-nowrap text-fg-subtle"><span title={utcDateTime(c.detected_at)}>{utcDate(c.detected_at)} {utcTime(c.detected_at, false)}</span></Td>140                    <Td mono>{c.kind}</Td>141                    <Td>{c.change_class ? <Chip tone={c.change_class === "pricing" || c.change_class === "policy" ? "high" : c.change_class === "meaningful" || c.change_class === "product" || c.change_class === "personnel" ? "signal" : "default"}>{CLASS_LABELS[c.change_class] ?? c.change_class}</Chip> : c.heuristic_type ? <Chip>{c.heuristic_type.replace(/_/g, " ")}</Chip> : <span className="text-fg-subtle">—</span>}</Td>142                    <Td mono className={c.signal >= 0.32 ? "text-signal" : "text-fg-subtle"}>{c.signal.toFixed(2)}{c.noise_ratio !== undefined && c.noise_ratio > 0 ? <span className="ml-1 text-[10.5px] text-fg-subtle">noise {Math.round(c.noise_ratio * 100)}%</span> : null}</Td>143                    <Td>{c.meaningful ? <Chip tone="ok" className="font-mono">YES</Chip> : <span className="font-mono text-[11px] text-fg-subtle">filtered</span>}</Td>144                    <Td className="min-w-[14rem]">{c.field_changes?.length ? <FieldChangeInline items={c.field_changes} max={2} /> : c.magnitude !== undefined && c.magnitude !== null ? <span className="font-mono text-[11px] text-fg-subtle">magnitude {c.magnitude}</span> : <span className="text-fg-subtle">—</span>}</Td>145                    <Td className="whitespace-nowrap">146                      {c.event_id && <Link href={`/event/${c.event_id}`} className="text-info hover:underline">event</Link>}147                      {c.event_id && c.old_snapshot_id && c.new_snapshot_id && <span className="text-fg-subtle"> · </span>}148                      {c.old_snapshot_id && c.new_snapshot_id && <Link href={`/compare?a=${c.old_snapshot_id}&b=${c.new_snapshot_id}`} className="text-info hover:underline">diff</Link>}149                      {!c.event_id && !(c.old_snapshot_id && c.new_snapshot_id) && <span className="text-fg-subtle">—</span>}150                    </Td>151                  </tr>152                ))}153              </Table>154            ) : (155              <Empty>No change detected yet.</Empty>156            )}157          </Panel>158159          <Panel title={`Runs · ${d.runs.length}`} dense>160            {d.runs.length ? (161              <Table head={["Started", "Outcome", "HTTP", "Duration", "Bytes", "Method", "Error"]}>162                {d.runs.map((r) => (163                  <tr key={r.id} className="hover:bg-panel-2/60">164                    <Td mono className="whitespace-nowrap text-fg-subtle">{utcDateTime(r.started_at)}</Td>165                    <Td><Chip tone={r.outcome === "event" ? "signal" : r.outcome === "changed" ? "info" : r.outcome === "error" || r.outcome === "parse_error" ? "danger" : r.outcome === "rate_limited" || r.outcome === "missing" ? "warn" : "default"}>{r.outcome}</Chip></Td>166                    <Td mono>{r.http_status ?? "—"}</Td>167                    <Td mono>{fmtMs(r.duration_ms)}</Td>168                    <Td mono>{fmtBytes(r.bytes)}</Td>169                    <Td mono className="text-fg-subtle">{r.fetch_method ?? "—"}</Td>170                    <Td className="max-w-[24rem] truncate text-danger" >{r.error ?? ""}</Td>171                  </tr>172                ))}173              </Table>174            ) : (175              <Empty>No run recorded in the retained window.</Empty>176            )}177          </Panel>178        </div>179180        <aside className="flex min-w-0 flex-col gap-4">181          <Panel title={`Events · ${events.length}`} dense action={<Link href={`/source/${s.source_id}`} className="text-[11px] text-fg-subtle hover:text-fg">source →</Link>}>182            {events.length ? events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No meaningful event from this sensor yet — raw changes that are noise never become events.</Empty>}183          </Panel>184          <Panel title="Conditional requests">185            <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">186              <dt className="text-fg-subtle">ETag</dt><dd className="min-w-0 truncate font-mono text-[11.5px]" title={s.etag ?? undefined}>{s.etag ?? <span className="text-fg-subtle">not exposed</span>}</dd>187              <dt className="text-fg-subtle">Last-Modified</dt><dd className="min-w-0 truncate font-mono text-[11.5px]" title={s.last_modified ?? undefined}>{s.last_modified ?? <span className="text-fg-subtle">not exposed</span>}</dd>188              <dt className="text-fg-subtle">Last change</dt><dd className="font-mono text-[11.5px]">{s.last_change_at ? relTime(s.last_change_at) : "—"}</dd>189              <dt className="text-fg-subtle">Last event</dt><dd className="font-mono text-[11.5px]">{s.last_event_at ? relTime(s.last_event_at) : "—"}</dd>190              <dt className="text-fg-subtle">Validated</dt><dd className="font-mono text-[11.5px]">{s.validated_at ? relTime(s.validated_at) : "—"}</dd>191            </dl>192          </Panel>193        </aside>194      </div>195    </>196  );197}198